Ordinary
About

Java Thread

profileordilov / 2022. 4. 30

Java Thread 생성

자바에서 스레드를 생성하는 방법은 2가지 입니다. Thread 클래스를 상속받거나 Runnable 인터페이스를 구현하여 스레드를 생성합니다.

class Runner extends Thread { @Override public void run() { ... } } class Runner implements Runnable { @Override public void run() { ... } }

쓰레드를 실행할 때는 쓰레드를 생성하고 start() 메소드를 호출하여 실행합니다.

Thread thread = new Thread(new Runner()); thread.start();

람다를 사용하면 클래스를 따로 생성하지 않고 익명 클래스를 생성하여 쓰레드를 생성할 수 있습니다.

Thread thread = new Thread(() -> { ... }); thread.start();

Thread 상속과 Runnable 구현 중에서 대체로 Runnable 구현이 더 나은 선택이 됩니다.

  • Thread를 상속 받으면 다른 클래스를 상속받지 못합니다.
  • Runnable은 람다로 대체할 수 있어 코드가 간결해집니다.

volatile 키워드

멀티 프로세서 환경에서 쓰레드 데이터의 정보는 CPU의 L1 캐시에 복사됩니다. 캐시에 복사된 값이 다른 쓰레드에서 변경되는 경우 캐시까지 업데이트가 이루어지지 않아 변경 이전의 값을 계속 읽을 수 있습니다. 그 이유는 프로세서는 메인 메모리에 쓰기 작업을 큐에 담아두었다가 특별한 작업을 할 시에 한 번에 씁니다. 쓰기 작업이 메인 메모리까지 변경하는 걸 보장해주지 않으면 다른 쓰레드에 쓰기 작업이 이루어지지 않을 수 있습니다. 이를 해결하기 위해 volatile 키워드를 사용합니다.

volatile boolean stop = false;

멀티 쓰레드 환경에서 일관성 있는 결과를 낼려면 두 가지를 제공해야 합니다.

  • 상호 배제
  • 메모리 가시성

이중에서 volatie은 메모리 가시성을 제공합니다. volaitle로 지정된 변수는 캐시가 아닌 메인 메모리에서 읽고 쓰게 됩니다. 이렇게 되면 어느 쓰레드에서 읽어도 같은 값을 보장합니다. 당연히 단점은 기존에는 쓰는 작업을 버퍼에 담아서 처리하고 캐시에서 읽던 작업들을 못하니 처리 비용이 많아집니다. 그리고 상호 배제 조건을 만족하지 못하기 때문에 동시 쓰기 작업에서 문제가 생길 수 있습니다.

synchronized 키워드

synchronized 키워드는 멀티 쓰레드 환경에서 필요한 상호 배제와 메모리 가시성을 둘 다 제공합니다. 사용할 수 있는 위치는 instance method, static method, code block입니다. instance와 static method의 경우 메소드 앞에 명시해주면 됩니다. code block의 경우 메서드 내부에 위치하며 잠금을 할 객체를 명시해줘야 합니다.

synchronized void method() { } synchronized static void method() { } void method() { synchronized (this) { } }

synchronize 가 붙은 메소드에 대해 동시에 하나의 쓰레드만 접근하게 하는 상호 배제를 제공합니다. 또한 volatile처럼 작업 이후에 메인 메모리까지 쓰기 작업을 반영해서 메모리 가시성을 제공합니다. 각각의 차이점을 간단하게 살펴보면 instance method의 경우 단일 객체에 lock이 걸립니다. 여러 쓰레드에서 단일 메서드에 접근할 때 synchronized 메서드 사용 시 하나의 쓰레드만 사용 가능합니다. static method의 경우 클래스 단위로 lock이 걸리며 instance method의 lock과 동기화되지 않습니다. code block의 경우 객체 안에서도 메서드마다 lock을 분리할 수 있습니다. 예시 코드로 든 this로 명시하는 경우 this로 명시한 메서드끼리는 동기화가 이루어집니다. 다른 lock을 주려면 다른 필드의 Object를 명시해주면 됩니다. code block으로 명시하는 경우 lock을 분리할 수도 있고 속도가 향상될 수 있습니다. 이럴 경우 lock을 거는 범위가 줄어들게 되고 성능이 향상될 수 있습니다. 다만 이런 동기화를 위한 객체는 외부에서 접근도 못하고 변경하지 못하게 하는 것이 안전합니다.

private final Object lock = new Object();

Thread Pool

쓰레드를 매번 생성하고 수거한다면 비용이 많이 들게 됩니다. 또 작업이 많아져서 쓰레드를 무한정 증가시킨다면 문제가 생기기 때문에, 개수를 제한할 필요가 있습니다. 이 때 스레드 풀을 사용하면 정해진 개수의 쓰레드 내에서 작업을 처리할 수 있습니다. 작업들은 큐에서 관리하고 사용 가능한 쓰레드가 있으면 태스크를 할당하게 됩니다.

단점은 작업보다 너무 많이 만들어 두면 메모리 낭비가 발생하고, 작업이 제대로 분배되지 않으면 유휴 쓰레드가 발생할 수 있습니다.

Thread Pool 생성

쓰레드 풀을 지원하기 위해 자바에서는 Executors, Executor, ExecutorService 를 제공합니다. Exectuor 는 execute 메소드를 가진 인터페이스입니다.

/** * Executes the given command at some time in the future. The command * may execute in a new thread, in a pooled thread, or in the calling * thread, at the discretion of the {@code Executor} implementation. */ void execute(Runnable command);

설명을 살펴보면 호출한 쓰레드나 쓰레드 풀에서 새로운 쓰레드에서 실행되는 것을 알 수 있습니다.

Executor executor = Executors.newSingleThreadExecutor(); executor.execute(() -> System.out.println("Hello"));

Executors 는 쓰레드 풀 인스턴스를 생성하는 메소드들을 제공해주는 헬퍼 클래스입니다. 그 중에서 ExecutorService 인터페이스는 쓰레드 실행과 종료를 관리하는 반환값으로 사용됩니다. 혹은 직접 ThreadPool을 생성하는 경우 ThreadPoolExecutor 클래스로 생성 가능합니다.

ExecutorService executor = Executors.newFixedThreadPool(2); ExecutorService executor = Executors.newCachedThreadPool(); ExecutorService executorService = new ThreadPoolExecutor( 1, 3, 20, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());

상황에 따라서 필요한 쓰레드 풀을 생성해서 사용하면 됩니다. 다만 쓰레드를 많이 만드는 경우 메모리가 부족한지 체크가 필요합니다.

CountDownLatch

CountDownLatch는 동시성 문제를 해결하기 위해 카운터 역할로 사용됩니다.

CountDownLatch latch = new CountDownLatch(5); latch.countDown(); latach.await();

주요 메서드는 countDown과 await입니다. countDown은 정해진 카운트가 0이 될때까지 대기합니다. 즉 쓰레드가 완료될 때 countDown을 실행해 쓰레드가 완료되었는지 체크가 가능해집니다. 완료될 때 이외에도 여러 쓰레드가 준비될 때까지 대기하는데도 사용할 수 있습니다. ExecutorService에서 쓰레드 풀에 task를 추가할 때 먼저 등록된 task는 쓰레드에 의해 먼저 시작됩니다. 이렇게 되면 다른 쓰레드가 시작하기 전에 먼저 시작된 쓰레드가 종료될 수도 있습니다. 동시에 처리되어야하는 작업의 경우 CountDownLatch를 통해 필요한 쓰레드 개수만큼 준비될 때까지 대기할 수 있습니다. 하지만 await를 명시했지만 countDown이 다 이뤄지지 않은 경우 계속 대기하게 됩니다. 따라서 CountDownLatch가 대기할 timeOut 시간을 지정해주는 것이 좋습니다.

boolean completed = countDownLatch.await(3L, TimeUnit.SECONDS);